@@ -12,3 +12,24 @@ class @Utils |
||
| 12 | 12 |
window.currentPage = new klass() |
| 13 | 13 |
else |
| 14 | 14 |
new klass() |
| 15 |
+ |
|
| 16 |
+ @showDynamicModal: (content = '', { title, body, onHide } = {}) ->
|
|
| 17 |
+ $("body").append """
|
|
| 18 |
+ <div class="modal fade" tabindex="-1" id='dynamic-modal' role="dialog" aria-labelledby="dynamic-modal-label" aria-hidden="true"> |
|
| 19 |
+ <div class="modal-dialog modal-lg"> |
|
| 20 |
+ <div class="modal-content"> |
|
| 21 |
+ <div class="modal-header"> |
|
| 22 |
+ <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button> |
|
| 23 |
+ <h4 class="modal-title" id="dynamic-modal-label"></h4> |
|
| 24 |
+ </div> |
|
| 25 |
+ <div class="modal-body">#{content}</div>
|
|
| 26 |
+ </div> |
|
| 27 |
+ </div> |
|
| 28 |
+ </div> |
|
| 29 |
+ """ |
|
| 30 |
+ modal = document.querySelector('#dynamic-modal')
|
|
| 31 |
+ $(modal).find('.modal-title').text(title || '').end().on 'hidden.bs.modal', ->
|
|
| 32 |
+ $('#dynamic-modal').remove()
|
|
| 33 |
+ onHide?() |
|
| 34 |
+ body?(modal.querySelector('.modal-body'))
|
|
| 35 |
+ $(modal).modal('show')
|
@@ -7,6 +7,8 @@ class @AgentEditPage |
||
| 7 | 7 |
if $("#agent_type").length
|
| 8 | 8 |
$("#agent_type").on "change", => @handleTypeChange(false)
|
| 9 | 9 |
@handleTypeChange(true) |
| 10 |
+ else |
|
| 11 |
+ @enableDryRunButton() |
|
| 10 | 12 |
|
| 11 | 13 |
handleTypeChange: (firstTime) -> |
| 12 | 14 |
$(".event-descriptions").html("").hide()
|
@@ -50,6 +52,8 @@ class @AgentEditPage |
||
| 50 | 52 |
$('.agent-options').html(json.form_options) if json.form_options?
|
| 51 | 53 |
window.jsonEditor = setupJsonEditor()[0] |
| 52 | 54 |
|
| 55 |
+ @enableDryRunButton() |
|
| 56 |
+ |
|
| 53 | 57 |
window.initializeFormCompletable() |
| 54 | 58 |
|
| 55 | 59 |
$("#agent-spinner").stop(true, true).fadeOut();
|
@@ -122,5 +126,39 @@ class @AgentEditPage |
||
| 122 | 126 |
else |
| 123 | 127 |
@hideEventCreation() |
| 124 | 128 |
|
| 129 |
+ enableDryRunButton: -> |
|
| 130 |
+ $(".agent-dry-run-button").prop('disabled', false).off().on "click", @invokeDryRun
|
|
| 131 |
+ |
|
| 132 |
+ disableDryRunButton: -> |
|
| 133 |
+ $(".agent-dry-run-button").prop('disabled', true)
|
|
| 134 |
+ |
|
| 135 |
+ invokeDryRun: (e) -> |
|
| 136 |
+ e.preventDefault() |
|
| 137 |
+ button = this |
|
| 138 |
+ $(button).prop('disabled', true)
|
|
| 139 |
+ $('body').css(cursor: 'progress')
|
|
| 140 |
+ $.ajax type: 'POST', url: $(button).data('action-url'), dataType: 'json', data: $(button.form).serialize()
|
|
| 141 |
+ .always => |
|
| 142 |
+ $("body").css(cursor: 'auto')
|
|
| 143 |
+ .done (json) => |
|
| 144 |
+ Utils.showDynamicModal """ |
|
| 145 |
+ <h5>Log</h5> |
|
| 146 |
+ <pre class="agent-dry-run-log"></pre> |
|
| 147 |
+ <h5>Events</h5> |
|
| 148 |
+ <pre class="agent-dry-run-events"></pre> |
|
| 149 |
+ <h5>Memory</h5> |
|
| 150 |
+ <pre class="agent-dry-run-memory"></pre> |
|
| 151 |
+ """, |
|
| 152 |
+ body: (body) -> |
|
| 153 |
+ $(body). |
|
| 154 |
+ find('.agent-dry-run-log').text(json.log).end().
|
|
| 155 |
+ find('.agent-dry-run-events').text(json.events).end().
|
|
| 156 |
+ find('.agent-dry-run-memory').text(json.memory)
|
|
| 157 |
+ title: 'Dry Run Results', |
|
| 158 |
+ onHide: -> $(button).prop('disabled', false)
|
|
| 159 |
+ .fail (xhr, status, error) -> |
|
| 160 |
+ alert('Error: ' + error)
|
|
| 161 |
+ $(button).prop('disabled', false)
|
|
| 162 |
+ |
|
| 125 | 163 |
$ -> |
| 126 | 164 |
Utils.registerPage(AgentEditPage, forPathsMatching: /^agents/) |
@@ -19,22 +19,10 @@ class @AgentShowPage |
||
| 19 | 19 |
$button = $(this) |
| 20 | 20 |
$button.on 'click', (e) -> |
| 21 | 21 |
e.preventDefault() |
| 22 |
- $("body").append """
|
|
| 23 |
- <div class="modal fade" tabindex="-1" id='dynamic-modal' role="dialog" aria-labelledby="dynamic-modal-label" aria-hidden="true"> |
|
| 24 |
- <div class="modal-dialog modal-lg"> |
|
| 25 |
- <div class="modal-content"> |
|
| 26 |
- <div class="modal-header"> |
|
| 27 |
- <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button> |
|
| 28 |
- <h4 class="modal-title" id="dynamic-modal-label"></h4> |
|
| 29 |
- </div> |
|
| 30 |
- <div class="modal-body"><pre></pre></div> |
|
| 31 |
- </div> |
|
| 32 |
- </div> |
|
| 33 |
- </div> |
|
| 34 |
- """ |
|
| 35 |
- $('#dynamic-modal').find('.modal-title').text $button.data('modal-title')
|
|
| 36 |
- $('#dynamic-modal').find('.modal-body pre').text $button.data('modal-content')
|
|
| 37 |
- $('#dynamic-modal').modal('show').on 'hidden.bs.modal', -> $('#dynamic-modal').remove()
|
|
| 22 |
+ Utils.showDynamicModal '<pre></pre>', |
|
| 23 |
+ title: $button.data('modal-title'),
|
|
| 24 |
+ body: (body) -> |
|
| 25 |
+ $(body).find('pre').text $button.data('modal-content')
|
|
| 38 | 26 |
|
| 39 | 27 |
$("#logs .spinner").stop(true, true).fadeOut ->
|
| 40 | 28 |
$("#logs .refresh, #logs .clear").show()
|
@@ -0,0 +1,64 @@ |
||
| 1 |
+module DryRunnable |
|
| 2 |
+ def dry_run! |
|
| 3 |
+ readonly! |
|
| 4 |
+ |
|
| 5 |
+ class << self |
|
| 6 |
+ prepend Sandbox |
|
| 7 |
+ end |
|
| 8 |
+ |
|
| 9 |
+ log = StringIO.new |
|
| 10 |
+ @dry_run_logger = Logger.new(log) |
|
| 11 |
+ @dry_run_results = {
|
|
| 12 |
+ events: [], |
|
| 13 |
+ } |
|
| 14 |
+ |
|
| 15 |
+ begin |
|
| 16 |
+ raise "#{short_type} does not support dry-run" unless can_dry_run?
|
|
| 17 |
+ check |
|
| 18 |
+ rescue => e |
|
| 19 |
+ error "Exception during dry-run. #{e.message}: #{e.backtrace.join("\n")}"
|
|
| 20 |
+ end |
|
| 21 |
+ |
|
| 22 |
+ @dry_run_results.update( |
|
| 23 |
+ memory: memory, |
|
| 24 |
+ log: log.string, |
|
| 25 |
+ ) |
|
| 26 |
+ end |
|
| 27 |
+ |
|
| 28 |
+ module Sandbox |
|
| 29 |
+ attr_accessor :results |
|
| 30 |
+ |
|
| 31 |
+ def logger |
|
| 32 |
+ @dry_run_logger |
|
| 33 |
+ end |
|
| 34 |
+ |
|
| 35 |
+ def save |
|
| 36 |
+ valid? |
|
| 37 |
+ end |
|
| 38 |
+ |
|
| 39 |
+ def save! |
|
| 40 |
+ save or raise ActiveRecord::RecordNotSaved |
|
| 41 |
+ end |
|
| 42 |
+ |
|
| 43 |
+ def log(message, options = {})
|
|
| 44 |
+ case options[:level] || 3 |
|
| 45 |
+ when 0..2 |
|
| 46 |
+ sev = Logger::DEBUG |
|
| 47 |
+ when 3 |
|
| 48 |
+ sev = Logger::INFO |
|
| 49 |
+ else |
|
| 50 |
+ sev = Logger::ERROR |
|
| 51 |
+ end |
|
| 52 |
+ |
|
| 53 |
+ logger.log(sev, message) |
|
| 54 |
+ end |
|
| 55 |
+ |
|
| 56 |
+ def create_event(event_hash) |
|
| 57 |
+ if can_create_events? |
|
| 58 |
+ @dry_run_results[:events] << event_hash[:payload] |
|
| 59 |
+ else |
|
| 60 |
+ error "This Agent cannot create events!" |
|
| 61 |
+ end |
|
| 62 |
+ end |
|
| 63 |
+ end |
|
| 64 |
+end |
@@ -1,5 +1,6 @@ |
||
| 1 | 1 |
class AgentsController < ApplicationController |
| 2 | 2 |
include DotHelper |
| 3 |
+ include ActionView::Helpers::TextHelper |
|
| 3 | 4 |
include SortableTable |
| 4 | 5 |
|
| 5 | 6 |
def index |
@@ -33,20 +34,53 @@ class AgentsController < ApplicationController |
||
| 33 | 34 |
end |
| 34 | 35 |
end |
| 35 | 36 |
|
| 37 |
+ def dry_run |
|
| 38 |
+ attrs = params[:agent] |
|
| 39 |
+ if agent = current_user.agents.find_by(id: params[:id]) |
|
| 40 |
+ # PUT /agents/:id/dry_run |
|
| 41 |
+ type = agent.type |
|
| 42 |
+ else |
|
| 43 |
+ # POST /agents/dry_run |
|
| 44 |
+ type = attrs.delete(:type) |
|
| 45 |
+ end |
|
| 46 |
+ agent = Agent.build_for_type(type, current_user, attrs) |
|
| 47 |
+ agent.name ||= '(Untitled)' |
|
| 48 |
+ |
|
| 49 |
+ if agent.valid? |
|
| 50 |
+ results = agent.dry_run! |
|
| 51 |
+ |
|
| 52 |
+ render json: {
|
|
| 53 |
+ log: results[:log], |
|
| 54 |
+ events: Utils.pretty_print(results[:events], false), |
|
| 55 |
+ memory: Utils.pretty_print(results[:memory] || {}, false),
|
|
| 56 |
+ } |
|
| 57 |
+ else |
|
| 58 |
+ render json: {
|
|
| 59 |
+ log: [ |
|
| 60 |
+ "#{pluralize(agent.errors.count, "error")} prohibited this Agent from being saved:",
|
|
| 61 |
+ *agent.errors.full_messages |
|
| 62 |
+ ].join("\n- "),
|
|
| 63 |
+ events: '', |
|
| 64 |
+ memory: '', |
|
| 65 |
+ } |
|
| 66 |
+ end |
|
| 67 |
+ end |
|
| 68 |
+ |
|
| 36 | 69 |
def type_details |
| 37 | 70 |
@agent = Agent.build_for_type(params[:type], current_user, {})
|
| 38 | 71 |
initialize_presenter |
| 39 | 72 |
|
| 40 |
- render :json => {
|
|
| 41 |
- :can_be_scheduled => @agent.can_be_scheduled?, |
|
| 42 |
- :default_schedule => @agent.default_schedule, |
|
| 43 |
- :can_receive_events => @agent.can_receive_events?, |
|
| 44 |
- :can_create_events => @agent.can_create_events?, |
|
| 45 |
- :can_control_other_agents => @agent.can_control_other_agents?, |
|
| 46 |
- :options => @agent.default_options, |
|
| 47 |
- :description_html => @agent.html_description, |
|
| 48 |
- :oauthable => render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent }),
|
|
| 49 |
- :form_options => render_to_string(partial: 'options', locals: { agent: @agent })
|
|
| 73 |
+ render json: {
|
|
| 74 |
+ can_be_scheduled: @agent.can_be_scheduled?, |
|
| 75 |
+ default_schedule: @agent.default_schedule, |
|
| 76 |
+ can_receive_events: @agent.can_receive_events?, |
|
| 77 |
+ can_create_events: @agent.can_create_events?, |
|
| 78 |
+ can_control_other_agents: @agent.can_control_other_agents?, |
|
| 79 |
+ can_dry_run: @agent.can_dry_run?, |
|
| 80 |
+ options: @agent.default_options, |
|
| 81 |
+ description_html: @agent.html_description, |
|
| 82 |
+ oauthable: render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent }),
|
|
| 83 |
+ form_options: render_to_string(partial: 'options', locals: { agent: @agent })
|
|
| 50 | 84 |
} |
| 51 | 85 |
end |
| 52 | 86 |
|
@@ -12,6 +12,7 @@ class Agent < ActiveRecord::Base |
||
| 12 | 12 |
include LiquidInterpolatable |
| 13 | 13 |
include HasGuid |
| 14 | 14 |
include LiquidDroppable |
| 15 |
+ include DryRunnable |
|
| 15 | 16 |
|
| 16 | 17 |
markdown_class_attributes :description, :event_description |
| 17 | 18 |
|
@@ -194,6 +195,10 @@ class Agent < ActiveRecord::Base |
||
| 194 | 195 |
self.class.can_control_other_agents? |
| 195 | 196 |
end |
| 196 | 197 |
|
| 198 |
+ def can_dry_run? |
|
| 199 |
+ self.class.can_dry_run? |
|
| 200 |
+ end |
|
| 201 |
+ |
|
| 197 | 202 |
def log(message, options = {})
|
| 198 | 203 |
AgentLog.log_for_agent(self, message, options) |
| 199 | 204 |
end |
@@ -328,6 +333,14 @@ class Agent < ActiveRecord::Base |
||
| 328 | 333 |
include? AgentControllerConcern |
| 329 | 334 |
end |
| 330 | 335 |
|
| 336 |
+ def can_dry_run! |
|
| 337 |
+ @can_dry_run = true |
|
| 338 |
+ end |
|
| 339 |
+ |
|
| 340 |
+ def can_dry_run? |
|
| 341 |
+ !!@can_dry_run |
|
| 342 |
+ end |
|
| 343 |
+ |
|
| 331 | 344 |
def gem_dependency_check |
| 332 | 345 |
@gem_dependencies_checked = true |
| 333 | 346 |
@gem_dependencies_met = yield |
@@ -5,6 +5,8 @@ module Agents |
||
| 5 | 5 |
class WebsiteAgent < Agent |
| 6 | 6 |
include WebRequestConcern |
| 7 | 7 |
|
| 8 |
+ can_dry_run! |
|
| 9 |
+ |
|
| 8 | 10 |
default_schedule "every_12h" |
| 9 | 11 |
|
| 10 | 12 |
UNIQUENESS_LOOK_BACK = 200 |
@@ -24,4 +24,7 @@ |
||
| 24 | 24 |
<% end %> |
| 25 | 25 |
<div class="form-group"> |
| 26 | 26 |
<%= submit_tag "Save", :class => "btn btn-primary" %> |
| 27 |
-</div> |
|
| 27 |
+ <% if agent.can_dry_run? %> |
|
| 28 |
+ <%= button_tag class: 'btn btn-default agent-dry-run-button', type: 'button', 'data-action-url' => agent.persisted? ? dry_run_agent_path(agent) : dry_run_agents_path do %><%= icon_tag('glyphicon-refresh') %> Dry Run<% end %>
|
|
| 29 |
+ <% end %> |
|
| 30 |
+</div> |
@@ -2,6 +2,7 @@ Huginn::Application.routes.draw do |
||
| 2 | 2 |
resources :agents do |
| 3 | 3 |
member do |
| 4 | 4 |
post :run |
| 5 |
+ put :dry_run |
|
| 5 | 6 |
post :handle_details_post |
| 6 | 7 |
put :leave_scenario |
| 7 | 8 |
delete :remove_events |
@@ -10,6 +11,7 @@ Huginn::Application.routes.draw do |
||
| 10 | 11 |
collection do |
| 11 | 12 |
post :propagate |
| 12 | 13 |
get :type_details |
| 14 |
+ post :dry_run |
|
| 13 | 15 |
get :event_descriptions |
| 14 | 16 |
post :validate |
| 15 | 17 |
post :complete |
@@ -0,0 +1,56 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+ |
|
| 3 |
+describe DryRunnable do |
|
| 4 |
+ class Agents::SandboxedAgent < Agent |
|
| 5 |
+ default_schedule "3pm" |
|
| 6 |
+ |
|
| 7 |
+ can_dry_run! |
|
| 8 |
+ |
|
| 9 |
+ def check |
|
| 10 |
+ log "Logging" |
|
| 11 |
+ create_event payload: { test: "foo" }
|
|
| 12 |
+ error "Recording error" |
|
| 13 |
+ create_event payload: { test: "bar" }
|
|
| 14 |
+ self.memory = { last_status: "ok" }
|
|
| 15 |
+ save! |
|
| 16 |
+ end |
|
| 17 |
+ end |
|
| 18 |
+ |
|
| 19 |
+ before do |
|
| 20 |
+ stub(Agents::SandboxedAgent).valid_type?("Agents::SandboxedAgent") { true }
|
|
| 21 |
+ |
|
| 22 |
+ @agent = Agents::SandboxedAgent.create(name: "some agent") { |agent|
|
|
| 23 |
+ agent.user = users(:bob) |
|
| 24 |
+ } |
|
| 25 |
+ end |
|
| 26 |
+ |
|
| 27 |
+ it "traps logging, event emission and memory updating" do |
|
| 28 |
+ results = nil |
|
| 29 |
+ |
|
| 30 |
+ expect {
|
|
| 31 |
+ results = @agent.dry_run! |
|
| 32 |
+ }.not_to change {
|
|
| 33 |
+ [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count] |
|
| 34 |
+ } |
|
| 35 |
+ |
|
| 36 |
+ expect(results[:log]).to match(/\AI, .+ INFO -- : Logging\nE, .+ ERROR -- : Recording error\n/) |
|
| 37 |
+ expect(results[:events]).to eq([{ test: 'foo' }, { test: 'bar' }])
|
|
| 38 |
+ expect(results[:memory]).to eq({ "last_status" => "ok" })
|
|
| 39 |
+ end |
|
| 40 |
+ |
|
| 41 |
+ it "does not perform dry-run if Agent does not support dry-run" do |
|
| 42 |
+ stub(@agent).can_dry_run? { false }
|
|
| 43 |
+ |
|
| 44 |
+ results = nil |
|
| 45 |
+ |
|
| 46 |
+ expect {
|
|
| 47 |
+ results = @agent.dry_run! |
|
| 48 |
+ }.not_to change {
|
|
| 49 |
+ [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count] |
|
| 50 |
+ } |
|
| 51 |
+ |
|
| 52 |
+ expect(results[:log]).to match(/\AE, .+ ERROR -- : Exception during dry-run. SandboxedAgent does not support dry-run: /) |
|
| 53 |
+ expect(results[:events]).to eq([]) |
|
| 54 |
+ expect(results[:memory]).to eq({})
|
|
| 55 |
+ end |
|
| 56 |
+end |
@@ -347,4 +347,31 @@ describe AgentsController do |
||
| 347 | 347 |
end |
| 348 | 348 |
end |
| 349 | 349 |
end |
| 350 |
+ |
|
| 351 |
+ describe "POST dry_run" do |
|
| 352 |
+ it "does not actually create any agent, event or log" do |
|
| 353 |
+ sign_in users(:bob) |
|
| 354 |
+ expect {
|
|
| 355 |
+ post :dry_run, agent: valid_attributes() |
|
| 356 |
+ }.not_to change {
|
|
| 357 |
+ [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count] |
|
| 358 |
+ } |
|
| 359 |
+ json = JSON.parse(response.body) |
|
| 360 |
+ expect(json['log']).to be_a(String) |
|
| 361 |
+ expect(json['events']).to be_a(String) |
|
| 362 |
+ expect(JSON.parse(json['events']).map(&:class)).to eq([Hash]) |
|
| 363 |
+ expect(json['memory']).to be_a(String) |
|
| 364 |
+ expect(JSON.parse(json['memory'])).to be_a(Hash) |
|
| 365 |
+ end |
|
| 366 |
+ |
|
| 367 |
+ it "does not actually update an agent" do |
|
| 368 |
+ sign_in users(:bob) |
|
| 369 |
+ agent = agents(:bob_weather_agent) |
|
| 370 |
+ expect {
|
|
| 371 |
+ post :dry_run, id: agents(:bob_website_agent), agent: valid_attributes(name: 'New Name') |
|
| 372 |
+ }.not_to change {
|
|
| 373 |
+ [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count, agent.name, agent.updated_at] |
|
| 374 |
+ } |
|
| 375 |
+ end |
|
| 376 |
+ end |
|
| 350 | 377 |
end |